MKMapViewで最大ズームしてもクラスタリングされてしまう時の回避方法
概要
大阪オフィスの山田です。MKMapViewでクラスタリングした時に、最大ズームしてもクラスタリングが解除されず、ピンが表示されない問題を自分なりに回避したので、その実装方法をメモとして残しておきます。今回はSwiftUIではなく、UIKitを使っています。
開発環境
- macOS: 10.15.4
- Xcode: 12.0.1
準備編
まず準備します。MKMapViewの上にピンを配置し、クラスタリングできるように実装します。
ViewControllerにMKMapViewを配置する
MapKitをimportして、ViewController上にMKMapViewを配置します。コードでMKMapViewを生成していますが、storyboardを使って配置しても同じです。
class ViewController: UIViewController { private var mapView: MKMapView! override func viewDidLoad() { super.viewDidLoad() mapView = MKMapView() mapView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(mapView) mapView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true mapView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true mapView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true mapView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true } }
地図にピンを配置する
地図にピンを配置するメソッドを作っておきます。
class ViewController: UIViewController { private var mapView: MKMapView! override func viewDidLoad() { // ...省略... addPoint(latitude: 34.7024, longitude: 135.4937) // 大阪駅 addPoint(latitude: 34.7331, longitude: 135.5002) // 新大阪駅 } } private extension ViewController { func addPoint(latitude: CLLocationDegrees, longitude: CLLocationDegrees) { let pin = MKPointAnnotation() pin.coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) mapView.addAnnotation(pin) } }
ピンの表示をカスタマイズする
MKMapViewDelegateを実装します。
class ViewController: UIViewController { private var mapView: MKMapView! override func viewDidLoad() { // ...省略... mapView.delegate = self mapView.register(CustomAnnotationView.self, forAnnotationViewWithReuseIdentifier: CustomAnnotationView.identifier) } } extension ViewController: MKMapViewDelegate { func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: CustomAnnotationView.identifier) as? CustomAnnotationView annotationView?.setup() return annotationView } /// ピンのAnnotationView class CustomAnnotationView: MKAnnotationView { static let identifier = "CustomAnnotationView" override func prepareForDisplay() { super.prepareForDisplay() image = UIImage.pinImage } func setup() { clusteringIdentifier = "StationCluster" } } extension UIImage { static let pinImage: UIImage? = { let size: CGFloat = 16.0 let contextSize = CGSize(width: size, height: size) UIGraphicsBeginImageContextWithOptions(contextSize, false, 0) defer { UIGraphicsEndImageContext() } let fillColor = UIColor.green let borderColor = UIColor.blue let circlePath = UIBezierPath(ovalIn: CGRect(x: 0.0, y: 0.0, width: size, height: size)) fillColor.setFill() circlePath.fill() borderColor.setStroke() circlePath.stroke() return UIGraphicsGetImageFromCurrentImageContext() }() }
CustomAnnotationView
を独自に定義しておきます。MKMapViewのdelegateメソッドmapView(viewFor:)
で画面に表示するAnnotationViewを設定します。dequeueReusableAnnotationView
を使うことで、AnnotationViewを再利用することが可能です。UIImageを拡張して、ピン用の画像を生成できるようにしています。CustomAnnotationView
のsetup
メソッドを定義して、clusteringIdentifier
を設定するようにします。これでクラスタリングされるようになります。
クラスタリングを使ってピンを配置する
クラスター化されたAnnotationViewをカスタムクラスとして定義します。
class CustomeClusterAnnotationView: MKAnnotationView { static let identifier = "CustomeClusterAnnotationView" }
次に、MKMapViewに登録しておきます。
override func viewDidLoad() { super.viewDidLoad() // ...省略... mapView.register(CustomeClusterAnnotationView.self, forAnnotationViewWithReuseIdentifier: CustomeClusterAnnotationView.identifier) }
mapView(viewFor:)でクラスター化されたAnnotationの場合、先ほど定義したCustomeClusterAnnotationView
を表示します。
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { // ...省略... if annotation is MKClusterAnnotation { return mapView.dequeueReusableAnnotationView(withIdentifier: CustomeClusterAnnotationView.identifier) } // ...省略... }
CustomeClusterAnnotationView
を表示するタイミングで、クラスター用の画像を設定するようにします。
class CustomeClusterAnnotationView: MKAnnotationView { static let identifier = "CustomeClusterAnnotationView" override func prepareForDisplay() { super.prepareForDisplay() if annotation is MKClusterAnnotation { image = UIImage.clusterImage(count: clusterAnnotation.memberAnnotations.count) } } } extension UIImage { static func clusterImage(count: Int) -> UIImage? { let size: CGFloat = 33.0 let contextSize = CGSize(width: size, height: size) UIGraphicsBeginImageContextWithOptions(contextSize, false, 0) defer { UIGraphicsEndImageContext() } let fillColor = UIColor.red let circlePath = UIBezierPath(ovalIn: CGRect(x: 0.0, y: 0.0, width: size, height: size)) fillColor.setFill() circlePath.fill() let text = count.description let attributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 15.0, weight: UIFont.Weight.bold), .foregroundColor: UIColor.white, ] let textRect = CGRect(origin: CGPoint.zero, size: CGSize(width: size, height: size)) let textBoundingRect = text.boundingRect( with: CGSize(width: textRect.width, height: textRect.height), options: .usesLineFragmentOrigin, attributes: attributes, context: nil) let finalRect = CGRect( x: textRect.midX - textBoundingRect.width / 2, y: textRect.midY - textBoundingRect.height / 2, width: textBoundingRect.width, height: textBoundingRect.height ) text.draw(in: finalRect, withAttributes: attributes) return UIGraphicsGetImageFromCurrentImageContext() } }
ここまでの実装で、地図をズームアウトした時にピンがクラスター化されます。これで準備編は完了です。準備編で用意したソースコードの全てを記載しておきます。
import UIKit import MapKit class ViewController: UIViewController { private var mapView: MKMapView! override func viewDidLoad() { super.viewDidLoad() // MKMapViewの生成と制約の追加 mapView = MKMapView() mapView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(mapView) mapView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true mapView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true mapView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true mapView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true // ピンを追加する addPoint(latitude: 34.7025, longitude: 135.4938) // 大阪駅 addPoint(latitude: 34.7331, longitude: 135.5002) // 新大阪駅 // 地図の初期表示を大阪駅周辺にする mapView.setRegion(MKCoordinateRegion.osakaStationCoordinateRegion, animated: false) mapView.delegate = self mapView.register(CustomAnnotationView.self, forAnnotationViewWithReuseIdentifier: CustomAnnotationView.identifier) mapView.register(CustomClusterAnnotationView.self, forAnnotationViewWithReuseIdentifier: CustomClusterAnnotationView.identifier) } } extension ViewController: MKMapViewDelegate { func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { if annotation is MKClusterAnnotation { return mapView.dequeueReusableAnnotationView(withIdentifier: CustomClusterAnnotationView.identifier) } let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: CustomAnnotationView.identifier) as? CustomAnnotationView annotationView?.setup() return annotationView } } private extension ViewController { /// ピンを追加する /// - Parameters: /// - latitude: 緯度 /// - longitude: 経度 func addPoint(latitude: CLLocationDegrees, longitude: CLLocationDegrees) { let pin = MKPointAnnotation() pin.coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) mapView.addAnnotation(pin) } } /// ピンのAnnotationView class CustomAnnotationView: MKAnnotationView { static let identifier = "CustomAnnotationView" override func prepareForDisplay() { super.prepareForDisplay() image = UIImage.pinImage } func setup() { clusteringIdentifier = "StationCluster" } } /// クラスター化されたピンのAnnotationView class CustomClusterAnnotationView: MKAnnotationView { static let identifier = "CustomClusterAnnotationView" override func prepareForDisplay() { super.prepareForDisplay() if let clusterAnnotation = annotation as? MKClusterAnnotation { image = UIImage.clusterImage(count: clusterAnnotation.memberAnnotations.count) } } } extension UIImage { /// ピンの画像 static let pinImage: UIImage? = { let size: CGFloat = 16.0 let contextSize = CGSize(width: size, height: size) UIGraphicsBeginImageContextWithOptions(contextSize, false, 0) defer { UIGraphicsEndImageContext() } let fillColor = UIColor.green let borderColor = UIColor.blue let circlePath = UIBezierPath(ovalIn: CGRect(x: 0.0, y: 0.0, width: size, height: size)) fillColor.setFill() circlePath.fill() borderColor.setStroke() circlePath.stroke() return UIGraphicsGetImageFromCurrentImageContext() }() /// クラスター化されたピンの画像を生成する /// - Parameters: /// - count: 中央に表示する数字 /// - Returns: クラスター化されたピンの画像 static func clusterImage(count: Int) -> UIImage? { let size: CGFloat = 33.0 let contextSize = CGSize(width: size, height: size) UIGraphicsBeginImageContextWithOptions(contextSize, false, 0) defer { UIGraphicsEndImageContext() } let fillColor = UIColor.red let circlePath = UIBezierPath(ovalIn: CGRect(x: 0.0, y: 0.0, width: size, height: size)) fillColor.setFill() circlePath.fill() let text = count.description let attributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 15.0, weight: UIFont.Weight.bold), .foregroundColor: UIColor.white, ] let textRect = CGRect(origin: CGPoint.zero, size: CGSize(width: size, height: size)) let textBoundingRect = text.boundingRect( with: CGSize(width: textRect.width, height: textRect.height), options: .usesLineFragmentOrigin, attributes: attributes, context: nil) let finalRect = CGRect( x: textRect.midX - textBoundingRect.width / 2, y: textRect.midY - textBoundingRect.height / 2, width: textBoundingRect.width, height: textBoundingRect.height ) text.draw(in: finalRect, withAttributes: attributes) return UIGraphicsGetImageFromCurrentImageContext() } } extension MKCoordinateRegion { /// 大阪駅周辺のRegion static let osakaStationCoordinateRegion: MKCoordinateRegion = { let center = CLLocationCoordinate2D(latitude: 34.7024, longitude: 135.4937) return MKCoordinateRegion(center: center, latitudinalMeters: 2000, longitudinalMeters: 2000) }() }
動作はこちらになります。
最大ズームした時にクラスタリングされてしまう問題の解決
地理的に近いピンを立ててみる
実際に近いピンを立ててみます。
override func viewDidLoad() { // ...省略... // ピンを追加する addPoint(latitude: 34.7025, longitude: 135.4938) // 大阪駅 addPoint(latitude: 34.73311, longitude: 135.50021) // 新大阪駅 その1 addPoint(latitude: 34.73312, longitude: 135.50022) // 新大阪駅 その2 // ...省略... }
新大阪駅にとても近いピンを立ててみます。すると以下のように最大ズームしても、クラスター化されたままで、それぞれのピンが個別に表示されなくなりました。
最大ズームしてもクラスター化が解除されない問題を解決する
まずズームしているレベルを取得できるようにします。
fileprivate extension MKMapView { var zoomLevel: Double { return log2(360.0 * ((Double(self.frame.size.width) / 256.0) / self.region.span.longitudeDelta)) + 1.0 } }
世界地図を縦横256のパネル何枚(2^xのx部分)で表示できるか、をズームレベルとします。frameを256で割ることで、端末のサイズに左右されずにズームレベルを算出できるようにしています。この考え方は以下の記事を参考にしています。
それでは以下のように実装していきます。
- クラスタリングするかしないかのフラグを持つ
- 初期ではtrueに設定する
- 一定以上ズームした場合は、クラスタリングするフラグを折る
- 一定以上ズームアウトした場合は、クラスタリングするフラグを立てる
- フラグに変更があった場合は、Annotationをセットし直して、ピンを再描画させる
class ViewController: UIViewController { // ...省略... /// クラスタリングを解除するズームレベルの閾値 private let clusteringZoomLevelThreshold = 19.0 /// クラスタリングを行うかどうかのフラグ private var clusteringSwitch: Bool = true { didSet { // Annotationの再描画をするために、annotationをMapに再セットする let annotations = mapView.annotations mapView.removeAnnotations(mapView.annotations) mapView.addAnnotations(annotations) } } // ...省略... } extension ViewController: MKMapViewDelegate { func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { if annotation is MKClusterAnnotation { return mapView.dequeueReusableAnnotationView(withIdentifier: CustomClusterAnnotationView.identifier) } let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: CustomAnnotationView.identifier) as? CustomAnnotationView annotationView?.setup(isEnableClustering: clusteringSwitch) return annotationView } func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { // ズーム、ズームアウトするタイミングでズームレベルが閾値を超えているか判断する // 閾値を超えた場合は、ON/OFFを切り替える if clusteringSwitch { if mapView.zoomLevel > clusteringZoomLevelThreshold { clusteringSwitch = false } } else { if mapView.zoomLevel <= clusteringZoomLevelThreshold { clusteringSwitch = true } } } } /// ピンのAnnotationView class CustomAnnotationView: MKAnnotationView { // ...省略... func setup(isEnableClustering: Bool) { // クラスタリングしない場合はnilをセットする clusteringIdentifier = isEnableClustering ? "StationCluster" : nil } } fileprivate extension MKMapView { var zoomLevel: Double { return log2(360.0 * ((Double(self.frame.size.width) / 256.0) / self.region.span.longitudeDelta)) + 1.0 } }
以上の実装を入れることで、ある一定以上ズームした場合はクラスタリングが解除され、ズームアウトしたら再度クラスタリングされるようになりました。
最後に
お店にピンを打つといった場合、東京だと同一建物内に2件あったりするので、わりとあるある問題だと思ってます。 人はなぜ都会に集まってしまうのか。